SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
23.7 KB · 390 lines tsx
Raw Blame History
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import type { ReactNode } from "react";5import { IMPORTANCE_WEIGHTS } from "@websensor/core/client";6import { DiffViewer } from "@/components/diff-viewer";7import { BookmarkButton } from "@/components/event-drawer";8import { EventRow } from "@/components/event-row";9import { FieldChangeInline, FieldChanges } from "@/components/field-changes";10import { Badge, Chip, Empty, EvidenceTag, ExtLink, Flag, Gauge, HealthPill, PageHeader, Panel, Score, StateBadge, Table, Td, TypeChip } from "@/components/ui";11import { ShareButton, WatchButton } from "@/components/watch-button";12import { api, SITE_URL, type SnapshotRow } from "@/lib/api";13import { CLASS_LABELS, fmtBytes, fmtMs, fmtOffset, fmtPct, fmtScore, relTime, shortHash, typeLabel, utcDateTime } from "@/lib/format";1415export const dynamic = "force-dynamic";1617export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {18  const { slug } = await params;19  const d = await api.event(slug);20  if (!d) return { title: "Event not found" };21  const e = d.event;22  const desc = e.summary.slice(0, 200);23  return {24    title: e.title,25    description: desc,26    alternates: { canonical: `/event/${e.slug}` },27    openGraph: { type: "article", title: e.title, description: desc, url: `${SITE_URL}/event/${e.slug}`, publishedTime: e.detected_at, tags: [e.event_type, ...e.categories] },28    twitter: { card: "summary_large_image", title: e.title, description: desc },29  };30}3132function Row({ label, value, tone = "" }: { label: string; value: ReactNode; tone?: string }) {33  return (34    <>35      <dt className="text-fg-subtle">{label}</dt>36      <dd className={`text-right font-mono tabular ${tone}`}>{value}</dd>37    </>38  );39}4041function Cell({ v, l }: { v: number | string; l: string }) {42  return (43    <div className="rounded-md border border-line bg-panel-2 px-2 py-1.5 text-center">44      <div className="font-mono text-base font-semibold tabular">{v}</div>45      <div className="label !text-[9.5px]">{l}</div>46    </div>47  );48}4950export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) {51  const { slug } = await params;52  const d = await api.event(slug);53  if (!d) notFound();54  const e = d.event;55  const change = e.change_id ? await api.change(e.change_id) : null;56  const interp = (e.interpretation ?? {}) as Record<string, unknown>;57  const observed = typeof interp.observed === "string" ? interp.observed : null;58  const inferred = typeof interp.inferred === "string" && interp.inferred.trim() ? interp.inferred : null;59  const who = typeof interp.who_it_affects === "string" ? interp.who_it_affects : null;60  const model = typeof interp.model === "string" ? interp.model : "heuristics-v1";61  const oldSnap = d.snapshots.find((s) => s.id === e.old_snapshot_id);62  const newSnap = d.snapshots.find((s) => s.id === e.new_snapshot_id);63  const subject = e.entities.find((x) => x.role === "subject") ?? e.entities[0];64  const signal = e.signal_score ?? e.importance;65  const cluster = e.cluster;66  const clusterHref = cluster ? `/cluster/${cluster.slug ?? cluster.id}` : d.cluster ? `/cluster/${d.cluster.slug ?? d.cluster.id}` : null;67  const leadTime = cluster?.lead_time_ms ?? d.cluster?.lead_time_ms ?? null;68  const hasFields = (e.field_changes?.length ?? 0) > 0;69  const diffKind = change?.change.kind ?? d.change?.kind ?? null;70  const diffSummary = change?.change.diff ?? d.change?.diff ?? null;71  const defaultTab = diffKind === "text" ? "split" : "semantic";72  const reasons = e.score_reasons ?? [];73  const history = d.history ?? [];74  const jsonLd = {75    "@context": "https://schema.org",76    "@type": "NewsArticle",77    headline: e.title,78    description: e.summary,79    datePublished: e.detected_at,80    dateModified: e.processed_at ?? e.detected_at,81    url: `${SITE_URL}/event/${e.slug}`,82    mainEntityOfPage: `${SITE_URL}/event/${e.slug}`,83    image: [`${SITE_URL}/event/${e.slug}/opengraph-image`],84    author: { "@type": "Organization", name: "WebSensor", url: SITE_URL },85    publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL },86    about: e.entities.map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })),87    isBasedOn: e.url,88    keywords: [e.event_type, ...e.categories, ...e.keywords].join(", "),89  };90  const snapRows: [string, SnapshotRow | undefined][] = [91    ["Before", oldSnap],92    ["After", newSnap],93  ];94  return (95    <>96      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />97      <PageHeader98        kicker={99          <span className="flex flex-wrap items-center gap-1.5">100            <Link href={`/source/${e.source?.id ?? e.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{e.source?.name ?? e.source_id}</Link>101            {e.country && <Flag code={e.country} className="text-[12px]" />}102            <StateBadge state={cluster?.state} />103            {e.silent_change && <Badge kind="silent" />}104            {e.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />}105            <EvidenceTag label={e.evidence_label} />106            <TypeChip type={e.event_type} href={`/live?event_type=${e.event_type}`} />107            {e.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}108          </span>109        }110        title={<span className="[overflow-wrap:anywhere]">{e.title}</span>}111        actions={112          <>113            <ShareButton path={`/event/${e.slug}`} />114            <BookmarkButton eventId={e.id} label />115            {subject && <WatchButton kind="entity" value={subject.id} label={`Watch ${subject.name}`} />}116            <Link href={subject ? `/alerts?entity=${encodeURIComponent(subject.id)}` : "/alerts"} className="inline-flex h-7 items-center rounded-md border border-line bg-panel px-2.5 text-[12px] hover:border-line-strong">Alert</Link>117          </>118        }119      />120      <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]">121        <div className="flex min-w-0 flex-col gap-4">122          {/* WHAT CHANGED */}123          <Panel title={<span>What changed{e.change_class && <span className="ml-2 normal-case tracking-normal text-fg-subtle">· {CLASS_LABELS[e.change_class] ?? e.change_class}</span>}</span>} action={<span className="font-mono text-[11px] text-fg-subtle tabular">{utcDateTime(e.detected_at)}</span>}>124            {hasFields ? (125              <>126                <FieldChanges items={e.field_changes} />127                <p className="mt-3 text-[13px] leading-relaxed text-fg-muted [overflow-wrap:anywhere]">{e.summary}</p>128              </>129            ) : (130              <p className="text-[15px] leading-relaxed [overflow-wrap:anywhere]">{e.summary}</p>131            )}132          </Panel>133134          {/* BEFORE / AFTER */}135          <div id="diff" className="scroll-mt-16">136            <div className="mb-2 flex flex-wrap items-center justify-between gap-2">137              <h2 className="label">Before / after{diffKind ? <span className="ml-2 normal-case tracking-normal text-fg-subtle">· {diffKind} diff</span> : null}</h2>138              <span className="flex flex-wrap gap-3 text-[11px] text-fg-subtle">139                {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="hover:text-fg">open in compare →</Link>}140                <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="hover:text-fg">URL history →</Link>141              </span>142            </div>143            {change || diffSummary ? (144              <DiffViewer unified={change?.unified ?? null} summary={diffSummary} defaultTab={defaultTab} />145            ) : (146              <Panel dense>147                <Empty>No diff is attached to this event{e.event_type === "page_removed" ? " — the page disappeared; its last snapshot is preserved below." : "."}</Empty>148              </Panel>149            )}150          </div>151152          {/* WHY IT MATTERS */}153          <Panel title={<span>Why it matters <span className="normal-case tracking-normal text-fg-subtle">· analysis</span></span>}>154            {e.why_it_matters ? <p className="text-[13.5px] leading-relaxed">{e.why_it_matters}</p> : <p className="text-[13px] text-fg-subtle">No analysis was generated for this event; the observed change speaks for itself.</p>}155            <div className="mt-3 grid gap-3 sm:grid-cols-2">156              <div className="rounded-md border border-line bg-panel-2 p-3">157                <div className="label mb-1 !text-ok">Observed</div>158                <p className="text-[13px]">{observed ?? "Facts are limited to the diff above: the monitored endpoint changed between the two preserved snapshots."}</p>159              </div>160              <div className="rounded-md border border-line bg-panel-2 p-3">161                <div className="label mb-1 !text-info">Inferred</div>162                <p className="text-[13px] text-fg-muted">{inferred ?? "No inference beyond the observed change."}</p>163                <p className="mt-1 font-mono text-[11px] text-fg-subtle">confidence {fmtScore(e.confidence)} · {model}</p>164              </div>165            </div>166            {who && (167              <div className="mt-3">168                <div className="label mb-1">Who it affects</div>169                <p className="text-[13px] text-fg-muted">{who}</p>170              </div>171            )}172          </Panel>173174          {/* EVIDENCE */}175          <Panel title="Evidence" dense>176            <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 px-3 py-2 text-[13px]">177              <span className="label">Monitored URL</span>178              <ExtLink href={e.url} className="min-w-0 break-all font-mono text-[12.5px]">{e.url}</ExtLink>179            </div>180            <Table head={["Snapshot", "Captured", "HTTP", "Size", "Canonical hash", "Raw hash", ""]}>181              {snapRows.map(([label, snap]) => (182                <tr key={label}>183                  <Td className="font-medium">{label}</Td>184                  <Td mono className="whitespace-nowrap text-fg-subtle">{snap ? utcDateTime(snap.captured_at) : "—"}</Td>185                  <Td mono>{snap?.http_status ?? "—"}</Td>186                  <Td mono className="whitespace-nowrap">{snap ? fmtBytes(snap.content_length) : "—"}</Td>187                  <Td mono className="text-fg-subtle"><span title={snap?.canonical_hash ?? undefined}>{shortHash(snap?.canonical_hash)}</span></Td>188                  <Td mono className="text-fg-subtle"><span title={snap?.content_hash ?? undefined}>{shortHash(snap?.content_hash)}</span></Td>189                  <Td className="whitespace-nowrap">190                    {snap ? (191                      snap.has_raw === false ? (192                        <span className="text-fg-subtle" title="Raw body pruned by retention; canonical preserved">view raw · pruned</span>193                      ) : (194                        <a href={`/api/v1/snapshots/${snap.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a>195                      )196                    ) : ""}197                  </Td>198                </tr>199              ))}200            </Table>201            {snapRows.some(([, s]) => s && s.has_raw === false) && <p className="px-3 pt-2 text-[11px] text-fg-subtle">Raw body pruned by retention; canonical preserved. Hashes and the canonical diff remain verifiable.</p>}202            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2 text-[12px]">203              <dt className="text-fg-subtle">Evidence hash</dt>204              <dd className="min-w-0 break-all font-mono text-fg-muted tabular">{newSnap?.canonical_hash ?? "—"}</dd>205              <dt className="text-fg-subtle">Sensor</dt>206              <dd className="min-w-0 truncate"><Link href={`/sensor/${e.sensor_id}`} className="text-info hover:underline">{e.sensor?.name ?? e.sensor_id}</Link> <span className="font-mono text-[11px] text-fg-subtle">{e.sensor?.type}{e.sensor?.connector ? ` · ${e.sensor.connector}` : ""}</span></dd>207              <dt className="text-fg-subtle">Trace</dt>208              <dd className="min-w-0 break-all font-mono text-[11px] text-fg-subtle">event {e.id} → change {e.change_id ?? "—"} → snapshots → sensor {e.sensor_id} → source {e.source_id}{e.processing_version ? ` · ${e.processing_version}` : ""}</dd>209            </dl>210            <div className="flex flex-wrap gap-3 border-t border-line px-3 py-2 text-[11.5px]">211              {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="text-info hover:underline">Compare snapshots</Link>}212              <Link href={`/sensor/${e.sensor_id}`} className="text-info hover:underline">Sensor</Link>213              <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="text-info hover:underline">URL history</Link>214              <Link href={`/source/${e.source?.id ?? e.source_id}`} className="text-info hover:underline">Source</Link>215            </div>216          </Panel>217218          {/* HISTORICAL CONTEXT */}219          <Panel title={<span>Previous changes on this page <span className="font-mono text-fg-subtle">{history.length}</span></span>} dense>220            {history.length ? (221              <ul className="divide-y divide-line">222                {history.map((h) => (223                  <li key={h.id} className="grid grid-cols-[5.5rem_1fr_auto] items-start gap-2 px-3 py-1.5 text-[12.5px]">224                    <span className="font-mono text-[11px] text-fg-subtle tabular" title={utcDateTime(h.detected_at)}>{relTime(h.detected_at)}</span>225                    <div className="min-w-0">226                      <div className="flex flex-wrap items-center gap-1.5">227                        <Link href={`/event/${h.slug}`} className="min-w-0 truncate hover:underline">{h.title}</Link>228                        {h.silent_change && <Badge kind="silent" compact />}229                      </div>230                      <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-fg-subtle">231                        <span>{typeLabel(h.event_type)}</span>232                        {h.field_changes && h.field_changes.length > 0 && <FieldChangeInline items={h.field_changes} max={2} />}233                      </div>234                    </div>235                    <Score value={h.importance} size="sm" />236                  </li>237                ))}238              </ul>239            ) : (240              <Empty>First change observed on this URL.</Empty>241            )}242          </Panel>243244          {/* RELATED SIGNALS */}245          <Panel title={<span>Related signals <span className="font-mono text-fg-subtle">{d.related.length}</span></span>} dense>246            {d.related.length ? d.related.map((r) => <EventRow key={r.id} ev={r} showDate />) : <Empty>No related signals yet.</Empty>}247          </Panel>248249          {/* CLUSTER / PROPAGATION */}250          {(cluster || d.cluster) && (251            <Panel title="Event cluster · propagation" action={clusterHref && <Link href={clusterHref} className="text-[11px] text-info hover:underline">propagation timeline →</Link>}>252              {d.cluster?.title && <div className="mb-2 text-[13px] font-medium [overflow-wrap:anywhere]">{d.cluster.title}</div>}253              <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">254                <Cell v={cluster?.event_count ?? d.cluster?.event_count ?? 1} l="signals" />255                <Cell v={cluster?.first_party_count ?? d.cluster?.first_party_count ?? 0} l="first-party" />256                <Cell v={cluster?.external_count ?? d.cluster?.external_count ?? 0} l="external" />257                <Cell v={cluster?.source_count ?? d.cluster?.source_count ?? 1} l="sources" />258              </div>259              {leadTime !== null && leadTime > 0 && (260                <p className="mt-3 rounded-md border border-signal/30 bg-signal-soft px-3 py-2 text-[12.5px]">261                  <span className="font-semibold text-signal">WebSensor lead time {fmtOffset(leadTime).replace("+", "")}</span> <span className="text-fg-muted">— the first-party change was observed before the first external report.</span>262                </p>263              )}264              <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-subtle">265                {(cluster?.state ?? d.cluster?.state) && <span>state <span className="font-mono uppercase text-fg-muted">{cluster?.state ?? d.cluster?.state}</span></span>}266                {(cluster?.velocity ?? d.cluster?.velocity) !== undefined && <span>velocity <span className="font-mono text-fg-muted tabular">{Math.round(cluster?.velocity ?? d.cluster?.velocity ?? 0)}</span></span>}267                {d.cluster && <span>{relTime(d.cluster.first_at)} → {relTime(d.cluster.last_at)}</span>}268              </div>269              {d.cluster && d.cluster.entity_ids.length > 0 && <div className="mt-2 flex flex-wrap gap-1">{d.cluster.entity_ids.slice(0, 8).map((id) => <Chip key={id} href={`/entity/${id}`}>{id.replace(/^(org|prd|ent)_/, "")}</Chip>)}</div>}270            </Panel>271          )}272        </div>273274        {/* RIGHT RAIL */}275        <aside className="flex min-w-0 flex-col gap-4">276          <Panel>277            <div className="flex items-center gap-3">278              <Score value={signal} size="lg" kind="signal" />279              <div className="min-w-0">280                <div className="label">WebSensor signal score</div>281                <div className="text-[12px] text-fg-muted">{signal >= 80 ? "attention now" : signal >= 60 ? "worth a look" : "informational"}</div>282              </div>283            </div>284            <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-3">285              <Gauge label="Importance" value={e.importance} />286              <Gauge label="Confidence" value={e.confidence} tone="info" />287              <Gauge label="Novelty" value={e.novelty} tone="signal" />288              <Gauge label="Impact" value={e.impact_score} tone="high" />289              <Gauge label="Velocity" value={e.velocity_score} tone="silent" />290              <Gauge label="Anomaly" value={e.anomaly_score} tone="mid" />291            </div>292            <div className="mt-4">293              <div className="label mb-1.5">Why this score</div>294              {reasons.length ? (295                <ul className="divide-y divide-line rounded-md border border-line text-[12px]">296                  {reasons.map((r, i) => (297                    <li key={i} className="flex items-start gap-2 px-2 py-1">298                      <span className={`w-2 shrink-0 font-mono font-semibold ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.sign}</span>299                      <span className="min-w-0 flex-1 text-fg-muted">{r.text}</span>300                      {r.points !== undefined && <span className={`shrink-0 font-mono tabular ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.points > 0 ? "+" : ""}{r.points}</span>}301                    </li>302                  ))}303                </ul>304              ) : (305                <p className="text-[12px] text-fg-subtle">Scored by heuristics only; no explicit adjustments were recorded for this event.</p>306              )}307            </div>308            {e.change_class && <div className="mt-3 text-[11.5px] text-fg-subtle">Semantic class <span className="font-medium text-fg-muted">{CLASS_LABELS[e.change_class] ?? e.change_class}</span></div>}309          </Panel>310311          <Panel title="Importance components" dense>312            <Table head={["Component", "Weight", "Score"]}>313              {IMPORTANCE_WEIGHTS.map((w) => (314                <tr key={w.key}>315                  <Td>{w.label}</Td>316                  <Td mono className="text-fg-subtle">{w.weight}%</Td>317                  <Td mono>{e.importance_components?.[w.key] !== undefined ? fmtScore(e.importance_components[w.key]) : "—"}</Td>318                </tr>319              ))}320            </Table>321          </Panel>322323          <Panel title="Timing">324            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">325              <Row label="Published" value={e.published_at ? utcDateTime(e.published_at) : "—"} />326              <Row label="Observed from" value={e.observed_from ? utcDateTime(e.observed_from) : "—"} />327              <Row label="Detected" value={utcDateTime(e.detected_at)} />328              <Row label="Processed" value={e.processed_at ? utcDateTime(e.processed_at) : "—"} />329              <Row label="To feed" value={e.published_to_feed_at ? utcDateTime(e.published_to_feed_at) : "—"} />330              <Row label="Detection latency" value={fmtMs(e.detection_latency_ms)} />331              <Row label="Processing latency" value={fmtMs(e.processing_latency_ms)} />332              {leadTime !== null && leadTime > 0 && <Row label="WebSensor lead time" value={fmtOffset(leadTime).replace("+", "")} tone="text-signal font-semibold" />}333            </dl>334            <p className="mt-2 text-[11px] text-fg-subtle">{relTime(e.detected_at)} · all times UTC</p>335          </Panel>336337          <Panel title="Entities" dense>338            {e.entities.length ? (339              <ul className="divide-y divide-line">340                {e.entities.map((x) => (341                  <li key={x.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[13px]">342                    <Link href={`/entity/${x.id}`} className="min-w-0 truncate hover:underline">{x.name}</Link>343                    <span className="shrink-0 font-mono text-[11px] text-fg-subtle">{x.type}{x.role === "subject" ? " · subject" : ""}</span>344                  </li>345                ))}346              </ul>347            ) : (348              <Empty>No entity resolved.</Empty>349            )}350          </Panel>351352          <Panel title="Source reliability">353            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">354              <dt className="text-fg-subtle">Sensor health</dt><dd className="text-right"><HealthPill health={d.sensor_reliability?.health} /></dd>355              <Row label="Connector success" value={fmtPct(d.sensor_reliability?.success_rate ?? null)} />356              <Row label="Latency" value={fmtMs(d.sensor_reliability?.avg_latency_ms)} />357              <Row label="Runs" value={d.sensor_reliability?.total_runs ?? "—"} />358              <Row label="Raw / meaningful" value={`${d.sensor_reliability?.raw_changes ?? "—"} / ${d.sensor_reliability?.meaningful_changes ?? "—"}`} />359            </dl>360            <p className="mt-2 text-[11px] text-fg-subtle">Sensor type {e.sensor?.type} via {e.sensor?.connector ?? "—"} connector · tier {e.sensor?.tier ?? e.source?.tier ?? "—"}{e.first_party === false ? " · third-party report" : " · first-party channel"}</p>361          </Panel>362363          <Panel title="Interpretation versions" dense>364            {d.interpretations.length ? (365              <ul className="divide-y divide-line">366                {d.interpretations.map((v) => (367                  <li key={v.version} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]">368                    <span>v{v.version} · <span className="font-mono">{v.model}</span></span>369                    <span className="font-mono text-[11px] text-fg-subtle">{utcDateTime(v.created_at)}</span>370                  </li>371                ))}372              </ul>373            ) : (374              <p className="px-3 py-2 text-[12px] text-fg-subtle">Heuristic interpretation only ({model}).</p>375            )}376            <p className="border-t border-line px-3 py-2 text-[11px] text-fg-subtle">Raw evidence is immutable; interpretations may be reprocessed and are versioned.</p>377          </Panel>378379          {e.keywords.length > 0 && (380            <Panel title="Keywords">381              <div className="flex flex-wrap gap-1">{e.keywords.map((k) => <Chip key={k} href={`/search?q=${encodeURIComponent(k)}`}>{k}</Chip>)}</div>382            </Panel>383          )}384          <p className="text-[11px] text-fg-subtle">{typeLabel(e.event_type)} · detected by WebSensor · AI-generated analysis never replaces the original evidence above.</p>385        </aside>386      </div>387    </>388  );389}390